-
Notifications
You must be signed in to change notification settings - Fork 112
feat: implement document history ui modal #199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: page-history
Are you sure you want to change the base?
feat: implement document history ui modal #199
Conversation
Reviewer's GuideImplements a document version history modal for document views, wiring it through the More Actions menu, exposing collab history APIs through the app context, and adjusting some shared UI primitives to support the new UX. Sequence diagram for opening document version history and restoring a versionsequenceDiagram
actor User
participant MoreActions as MoreActions
participant MoreActionsContent as MoreActionsContent
participant DocumentHistoryModal as DocumentHistoryModal
participant AppHandlers as useAppHandlers
participant AFClientService as AFClientService
participant HttpAPI as http_api_getCollabVersions
participant SyncContext as SyncInternal_revert
User->>MoreActions: click_more_actions_button
MoreActions->>MoreActions: setOpen(true)
MoreActions->>MoreActionsContent: render_with(viewId, onOpenHistory)
User->>MoreActionsContent: select_VersionHistory_item
MoreActionsContent->>MoreActions: onOpenHistory()
MoreActions->>MoreActions: handleOpenHistory()
MoreActions->>MoreActions: setOpen(false)
MoreActions->>MoreActions: setHistoryOpen(true)
MoreActions->>DocumentHistoryModal: render(open=true, viewId)
DocumentHistoryModal->>AppHandlers: getCollabHistory(viewId)
AppHandlers->>AFClientService: getCollabHistory(workspaceId, viewId, since)
AFClientService->>HttpAPI: getCollabVersions(workspaceId, viewId, since)
HttpAPI-->>AFClientService: CollabVersionRecord[]
AFClientService-->>AppHandlers: CollabVersionRecord[]
AppHandlers-->>DocumentHistoryModal: CollabVersionRecord[]
DocumentHistoryModal->>DocumentHistoryModal: setVersions()
DocumentHistoryModal->>DocumentHistoryModal: setSelectedVersionId(firstVersion)
User->>DocumentHistoryModal: select_version_in_list
DocumentHistoryModal->>DocumentHistoryModal: setSelectedVersionId(versionId)
User->>DocumentHistoryModal: click_Restore
DocumentHistoryModal->>AppHandlers: revertCollabVersion(viewId, versionId)
AppHandlers->>SyncContext: revertCollabVersion(viewId, versionId)
SyncContext-->>AppHandlers: Promise_resolved
AppHandlers-->>DocumentHistoryModal: Promise_resolved
DocumentHistoryModal->>DocumentHistoryModal: clear_preview_cache
DocumentHistoryModal->>AppHandlers: getCollabHistory(viewId)
AppHandlers->>AFClientService: getCollabHistory(workspaceId, viewId, since)
AFClientService->>HttpAPI: getCollabVersions(...)
HttpAPI-->>AFClientService: CollabVersionRecord[]
AFClientService-->>AppHandlers: CollabVersionRecord[]
AppHandlers-->>DocumentHistoryModal: CollabVersionRecord[]
DocumentHistoryModal->>DocumentHistoryModal: update_versions_and_selection
User->>DocumentHistoryModal: close_modal
DocumentHistoryModal->>MoreActions: onOpenChange(false)
MoreActions->>MoreActions: setHistoryOpen(false)
Updated class diagram for collab history services and document history UIclassDiagram
class CollabVersionRecord {
+string versionId
+string viewId
+string parentId
+string name
+Date createdAt
+boolean isDeleted
+number[] editors
}
class EncodedCollab {
+Uint8Array stateVector
+Uint8Array docState
+string version
}
class CollabHistoryService {
<<interface>>
+getCollabHistory(workspaceId: string, viewId: string, since: Date) Promise~CollabVersionRecord[]~
+previewCollabVersion(workspaceId: string, viewId: string, versionId: string, collabType: Types) Promise~Uint8Array~
+createCollabVersion(workspaceId: string, viewId: string, name: string, snapshot: Uint8Array) Promise~string~
+deleteCollabVersion(workspaceId: string, viewId: string, versionId: string) Promise~void~
+revertCollabVersion(workspaceId: string, viewId: string, collabType: Types, versionId: string) Promise~EncodedCollab~
}
class AFClientService {
+getCollabHistory(workspaceId: string, viewId: string, since: Date) Promise~CollabVersionRecord[]~
+previewCollabVersion(workspaceId: string, viewId: string, versionId: string, collabType: Types) Promise~Uint8Array~
+createCollabVersion(workspaceId: string, viewId: string, name: string, snapshot: Uint8Array) Promise~string~
+deleteCollabVersion(workspaceId: string, viewId: string, versionId: string) Promise~void~
+revertCollabVersion(workspaceId: string, viewId: string, collabType: Types, versionId: string) Promise~EncodedCollab~
}
CollabHistoryService <|.. AFClientService
class AppContextType {
+getCollabHistory(viewId: string) Promise~CollabVersionRecord[]~
+previewCollabVersion(viewId: string, versionId: string) Promise~YDoc~
+revertCollabVersion(viewId: string, versionId: string) Promise~void~
+loadMentionableUsers() Promise~MentionablePerson[]~
}
class BusinessInternalContextType {
+getCollabHistory(viewId: string, since: Date) Promise~CollabVersionRecord[]~
+previewCollabVersion(viewId: string, versionId: string, collabType: Types) Promise~YDoc~
+revertCollabVersion(viewId: string, versionId: string, collabType: Types) Promise~void~
}
class SyncInternalContextType {
+revertCollabVersion(viewId: string, version: string) Promise~void~
}
class useViewOperations {
+getCollabHistory(viewId: string, since: Date) Promise~CollabVersionRecord[]~
+previewCollabVersion(viewId: string, versionId: string, collabType: Types) Promise~YDoc~
}
class AppBusinessLayer {
+getCollabHistory(viewId: string, since: Date) Promise~CollabVersionRecord[]~
+revertCollabVersion(viewId: string, versionId: string, collabType: Types) Promise~void~
}
AppBusinessLayer --> useViewOperations : uses
AppBusinessLayer --> SyncInternalContextType : uses
AppContextType --> AppBusinessLayer : provided_by
class DocumentHistoryModal {
+boolean open
+string viewId
+ViewIcon icon
+CollabVersionRecord[] versions
+string selectedVersionId
+YDoc activeDoc
+boolean isRestoring
+render()
+refreshVersions() Promise~CollabVersionRecord[]~
+handleRestore() Promise~void~
}
class VersionList {
+CollabVersionRecord[] versions
+string selectedVersionId
+boolean isPro
+string dateFilter
+boolean onlyShowMine
+onSelect(versionId: string) void
+onDateFilterChange(filter: string) void
+onOnlyShowMineChange(onlyShowMine: boolean) void
+onRestoreClicked() void
}
class MoreActions {
+boolean open
+boolean historyOpen
+handleOpenHistory() void
}
class MoreActionsContent {
+onOpenHistory() void
+itemClicked() void
}
MoreActions --> MoreActionsContent : renders
MoreActions --> DocumentHistoryModal : toggles_open
DocumentHistoryModal --> VersionList : renders
DocumentHistoryModal --> AppContextType : uses_handlers
AppContextType --> AFClientService : delegates_collab_calls
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
🥷 Ninja i18n – 🛎️ Translations need to be updatedProject
|
| lint rule | new reports | level | link |
|---|---|---|---|
| Missing translation | 336 | warning | contribute (via Fink 🐦) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey there - I've reviewed your changes and found some issues that need to be addressed.
- The new
previewCollabVersionpath isn’t actually wired through the context:useViewOperationsexposes it, butAppBusinessLayeronly pullsgetCollabHistory, souseAppHandlers().previewCollabVersionwill beundefinedandDocumentHistoryModal's preview logic will silently no-op; consider passingpreviewCollabVersionthroughAppBusinessLayerandBusinessInternalContextthe same way asgetCollabHistory. - In
DocumentHistoryModal’s restore handler you callvoid revertCollabVersion(viewId, selectedVersionId)but then immediately refresh versions; sincerevertCollabVersionis async, you likely want toawaitit so the history list reflects the restored state reliably. - The
authorMapprop computed inDocumentHistoryModaland passed intoVersionListis not used anywhere inDocumentHistoryVersionList.tsx; either use it to surface author info in the UI or remove it from both components to avoid dead code.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The new `previewCollabVersion` path isn’t actually wired through the context: `useViewOperations` exposes it, but `AppBusinessLayer` only pulls `getCollabHistory`, so `useAppHandlers().previewCollabVersion` will be `undefined` and `DocumentHistoryModal`'s preview logic will silently no-op; consider passing `previewCollabVersion` through `AppBusinessLayer` and `BusinessInternalContext` the same way as `getCollabHistory`.
- In `DocumentHistoryModal`’s restore handler you call `void revertCollabVersion(viewId, selectedVersionId)` but then immediately refresh versions; since `revertCollabVersion` is async, you likely want to `await` it so the history list reflects the restored state reliably.
- The `authorMap` prop computed in `DocumentHistoryModal` and passed into `VersionList` is not used anywhere in `DocumentHistoryVersionList.tsx`; either use it to surface author info in the UI or remove it from both components to avoid dead code.
## Individual Comments
### Comment 1
<location> `src/components/document/history/DocumentHistoryModal.tsx:83-84` </location>
<code_context>
+ return true;
+ });
+
+ if (filtered.length > 0 && !filtered.some((version) => version.versionId === selectedVersionId)) {
+ setSelectedVersionId(filtered[0].versionId);
+ }
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid calling `setSelectedVersionId` inside `useMemo` to prevent state updates during render.
Calling `setSelectedVersionId` here introduces a side effect during render, which can trigger React warnings (`Cannot update a component while rendering a different component`) and cause subtle bugs. Move this logic into a `useEffect` keyed on `visibleVersions`/`selectedVersionId`, or compute a local `resolvedSelectedVersionId` instead of updating state inside `useMemo`.
</issue_to_address>
### Comment 2
<location> `src/components/document/history/DocumentHistoryModal.tsx:191-198` </location>
<code_context>
+
+ useEffect(() => {
+ void (async () => {
+ const cachedDoc = previewYDocRef.current.get(selectedVersionId);
+
+ if (cachedDoc) {
+ setActiveDoc(cachedDoc);
+ return;
+ }
+
+ if (!viewId || !previewCollabVersion) {
+ return;
+ }
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard the preview effect when no version is selected to avoid invalid preview calls.
If `selectedVersionId` can be an empty string, this effect may still call `previewCollabVersion(viewId, '')`, resulting in invalid requests and noisy errors. Consider an early return, e.g. `if (!selectedVersionId) return;`, before using it.
</issue_to_address>
### Comment 3
<location> `src/components/document/history/DocumentHistoryModal.tsx:152-156` </location>
<code_context>
+ setIsRestoring(true);
+ setError(null);
+ try {
+ void revertCollabVersion(viewId, selectedVersionId);
+ previewYDocRef.current.clear();
+ setActiveDoc(null);
+
+ const updatedVersions = await refreshVersions();
+
+ if (updatedVersions.length > 0) {
</code_context>
<issue_to_address>
**issue (bug_risk):** `revertCollabVersion` is not awaited, which can cause race conditions with the subsequent refresh.
Because `revertCollabVersion` is intentionally ignored with `void`, `refreshVersions()` may complete before the revert finishes, so the updated list can still reflect the old state. Awaiting `revertCollabVersion(viewId, selectedVersionId)` would ensure the history refresh happens only after the revert completes and that `isRestoring` covers the full restore operation.
</issue_to_address>
### Comment 4
<location> `src/components/app/layers/AppBusinessLayer.tsx:260-261` </location>
<code_context>
awarenessMap,
getViewIdFromDatabaseId,
getViewReadOnlyStatus,
+ getCollabHistory,
+ previewCollabVersion,
};
</code_context>
<issue_to_address>
**issue (bug_risk):** `previewCollabVersion` is not exposed through the business layer, so the modal cannot use it.
The modal currently expects `previewCollabVersion` from `useAppHandlers`, but `AppBusinessLayer` only injects `getCollabHistory` and `revertCollabVersion` into the context. This means `previewCollabVersion` will be `undefined` and previews will never load. Please pass `previewCollabVersion` through from `useViewOperations` into the context and add it to `AppContextType`.
</issue_to_address>
### Comment 5
<location> `src/components/app/app.hooks.tsx:98` </location>
<code_context>
getViewIdFromDatabaseId?: (databaseId: string) => Promise<string | null>;
loadMentionableUsers?: () => Promise<MentionablePerson[]>;
+ getCollabHistory?: (viewId: string) => Promise<CollabVersionRecord[]>;
+ previewCollabVersion?: (viewId: string, versionId: string) => Promise<YDoc>;
+ revertCollabVersion?: (viewId: string, versionId: string) => Promise<void>;
}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Align `previewCollabVersion` signatures across hooks, context, and services to avoid type and runtime inconsistencies.
Here `previewCollabVersion` is declared as `(viewId, versionId) => Promise<YDoc>`, but `useViewOperations.previewCollabVersion` expects `(viewId, versionId, collabType: Types)` and only returns a `Y.Doc` when `collabType === Types.Document` (otherwise `undefined`). This discrepancy can cause incorrect calls and hides the need to pass `collabType`. Consider either:
- Adding a higher-level helper that always uses `Types.Document` and returns `Promise<YDoc>`, and wiring that through the UI; or
- Updating the context/service types to include `collabType` and the correct return union so all layers share the same contract.
Suggested implementation:
```typescript
import { AppBusinessLayer } from './layers/AppBusinessLayer';
import { AppSyncLayer } from './layers/AppSyncLayer';
import { CollabVersionRecord } from '@/application/collab-version.type';
import { Types } from '@/application/collab.types';
```
```typescript
loadMentionableUsers?: () => Promise<MentionablePerson[]>;
getCollabHistory?: (viewId: string) => Promise<CollabVersionRecord[]>;
previewCollabVersion?: (
viewId: string,
versionId: string,
collabType: Types,
) => Promise<YDoc | undefined>;
revertCollabVersion?: (viewId: string, versionId: string) => Promise<void>;
}
```
1. Ensure the `Types` import path is correct for your codebase; replace `'@/application/collab.types'` with the actual module that exports the `Types` enum used by `useViewOperations.previewCollabVersion`.
2. Update the implementation of `previewCollabVersion` in the AppContext provider (where `context.previewCollabVersion` is defined) so its function signature matches the updated `AppContextType` and forwards `collabType` to `useViewOperations.previewCollabVersion`.
3. Fix any call sites of `previewCollabVersion` that currently pass only `(viewId, versionId)` to also pass `collabType`, or introduce a higher-level helper (e.g. `previewDocumentCollabVersion(viewId, versionId)`) that always uses `Types.Document` and returns `Promise<YDoc>` if that's the desired UI contract.
</issue_to_address>
### Comment 6
<location> `src/components/document/history/DocumentHistoryModal.tsx:55` </location>
<code_context>
+ const [activeDoc, setActiveDoc] = useState<Y.Doc | null>(null);
+ const [isRestoring, setIsRestoring] = useState(false);
+
+ const visibleVersions = useMemo(() => {
+ let filtered = [...versions];
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider keeping the `visibleVersions` useMemo pure and moving the selection-sync side effect into a dedicated useEffect to make the data flow clearer.
The `visibleVersions` `useMemo` doing a `setSelectedVersionId` is the one spot that meaningfully increases complexity and makes the data flow harder to reason about.
You can keep all behavior while making `useMemo` pure by moving the selection-sync logic into a separate `useEffect`:
```ts
const visibleVersions = useMemo(() => {
let filtered = [...versions];
if (onlyShowMine && currentUser) {
filtered = filtered.filter((version) =>
version.editors.some((editor) => editor.toString() === currentUser.uid)
);
}
const now = new Date();
filtered = filtered.filter((version) => {
if (dateFilter === 'all') return true;
const diffTime = Math.abs(now.getTime() - version.createdAt.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (dateFilter === 'last7Days') return diffDays <= 7;
if (dateFilter === 'last30Days') return diffDays <= 30;
if (dateFilter === 'last60Days') return diffDays <= 60;
return true;
});
return filtered;
}, [versions, onlyShowMine, currentUser, dateFilter]);
```
Then add an effect that mirrors the existing behavior of picking the first visible version when the current selection is not present:
```ts
useEffect(() => {
if (
visibleVersions.length > 0 &&
!visibleVersions.some((version) => version.versionId === selectedVersionId)
) {
setSelectedVersionId(visibleVersions[0].versionId);
}
}, [visibleVersions, selectedVersionId]);
```
This keeps `useMemo` pure, avoids hidden state changes during render, and makes the selection logic explicit and easier to debug.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
c89c256 to
c86022c
Compare
Description
Checklist
General
Testing
Feature-Specific
Summary by Sourcery
Add a document version history experience accessible from the page more-actions menu, including a modal with a read-only editor preview and timeline of versions backed by new collaboration history APIs.
New Features:
Bug Fixes:
Enhancements: